如何调试多线程程序 您所在的位置:网站首页 进程 程序 如何调试多线程程序

如何调试多线程程序

2023-09-07 19:42| 来源: 网络整理| 查看: 265

在上一篇文章《使用 gdb 调试多进程程序 —— 以调试 nginx 为例》我们介绍了如何使用 gdb 调试多进程程序,这篇文章我们来介绍下如何使用 gdb 调试多线程程序,同时这个方法也是我阅读和分析一个新的 C/C++ 项目常用的方法。

当然,多线程调试的前提是你需要熟悉多线程的基础知识,包括线程的创建和退出、线程之间的各种同步原语等。如果您还不熟悉多线程编程的内容,可以参考这个专栏《C++ 多线程编程专栏》,如果您不熟悉 gdb 调试可以参考这个专栏《Linux GDB 调试教程》。

一、调试多线程的方法

使用 gdb 将程序跑起来,然后按 Ctrl + C 将程序中断下来,使用 info threads 命令查看当前进程有多少线程。

还是以 redis-server 为例,当使用 gdb 将程序运行起来后,我们按 Ctrl + C 将程序中断下来,此时可以使用 info threads 命令查看 redis-server 有多少线程,每个线程正在执行哪里的代码。

使用 thread 线程编号 可以切换到对应的线程去,然后使用 bt 命令可以查看对应线程从顶到底层的函数调用,以及上层调用下层对应的源码中的位置;当然,你也可以使用 frame 栈函数编号(栈函数编号即下图中的 #0 ~ #4,使用 frame 命令时不需要加 #)切换到当前函数调用堆栈的任何一层函数调用中去,然后分析该函数执行逻辑,使用 print 等命令输出各种变量和表达式值,或者进行单步调试。

如上图所示,我们切换到了 redis-server 的 1 号线程,然后输入 bt 命令查看该线程的调用堆栈,发现顶层是 main 函数,说明这是主线程,同时得到从 main 开始往下各个函数调用对应的源码位置,我们可以通过这些源码位置来学习研究调用处的逻辑。对每个线程都进行这样的分析之后,我们基本上就可以搞清楚整个程序运行中的执行逻辑了。

接着我们分别通过得到的各个线程的线程函数名去源码中搜索,找到创建这些线程的函数(下文为了叙述方便,以 f 代称这个函数),再接着通过搜索 f 或者给 f 加断点重启程序看函数 f 是如何被调用的,这些操作一般在程序初始化阶段。

redis-server 1 号线线程是在 main 函数中创建的,我们再看下 2 号线程的创建,使用 thread 2 切换到 2号线程,然后使用 bt 命令查看 2 号线程的调用堆栈,得到 2 号线程的线程函数为 bioProcessBackgroundJobs,注意在顶层的 clone 和 start_thread 是系统函数,我们找的线程函数应该是项目中的自定义线程函数。

通过在项目中搜索 bioProcessBackgroundJobs 函数,我们发现 bioProcessBackgroundJobs 函数在 bioInit 中被调用,而且确实是在 bioInit 函数中创建了线程 2,因此我们看到了 pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) 这样的调用。

//bio.c 96行 void bioInit(void) { //...省略部分代码... for (j = 0; j < BIO_NUM_OPS; j++) { void *arg = (void*)(unsigned long) j; //在这里创建了线程 bioProcessBackgroundJobs if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) { serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs."); exit(1); } bio_threads[j] = thread; } }

此时,我们可以继续在项目中查找 bioInit 函数,看看它在哪里被调用的,或者直接给 bioInit 函数加上断点,然后重启 redis-server,等断点触发,使用 bt 命令查看此时的调用堆栈就知道 bioInit 函数在何处调用的了。

(gdb) b bioInit Breakpoint 1 at 0x498e5e: file bio.c, line 103. (gdb) r The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /root/redis-6.0.3/src/redis-server [Thread debugging using libthread_db enabled] //...省略部分无关输出... Breakpoint 1, bioInit () at bio.c:103 103 for (j = 0; j < BIO_NUM_OPS; j++) { (gdb) bt #0 bioInit () at bio.c:103 #1 0x0000000000431b5d in InitServerLast () at server.c:2953 #2 0x000000000043724f in main (argc=1, argv=0x7fffffffe318) at server.c:5142 (gdb)

至此我们发现 2 号线程是在 main 函数中调用了 InitServerLast 函数,后者又调用 bioInit 函数,然后在 bioInit 函数中创建了新的线程 bioProcessBackgroundJobs,我们只要分析这个执行流就能搞清楚这个逻辑流程了。

同样的道理,redis-server 还有 3 号和 4 号线程,我们也可以按分析 2 号线程的方式去分析 3 号和 4号,读者可以按照这里介绍的方法。

以上就是我阅读一个不熟悉的 C/C++ 项目常用的方法,当然对于一些特殊的项目的源码,你还需要去了解一下该项目的的业务内容,否则除了技术逻辑以外,你可能需要一些业务知识才能看懂各个线程调用栈以及初始化各个线程函数过程中的业务逻辑。

二、调试时控制线程切换

在调试多线程程序时,有时候我们希望执行流一直在某个线程执行,而不是切换到其他线程,有办法做到这样吗?

为了说明清楚这个问题,我们假设现在调试的程序有 5 个线程,除了主线程,其他 4 个工作线程的线程函数都是下面这样一个函数:

void* worker_thread_proc(void* arg) { while (true) { //代码行1 //代码行2 //代码行3 //代码行4 //代码行5 //代码行6 //代码行7 //代码行8 //代码行9 //代码行10 //代码行11 //代码行12 //代码行13 //代码行14 //代码行15 } }

为了方便表述,我们把四个工作线程分别叫做 A、B、C、D。

如上图所示,假设某个时刻, 线程 A 的停在代码行 3 处,线程 B、C、D 停留位置代码行 1 ~15 任一位置,此时线程 A 是 gdb 当前调试线程,此时我们输入 next 命令,期望调试器跳转到代码行 4 处;或者输入 util 10 命令,期望调试器跳转到**代码行 10 **处。但是实际情况下,如果代码行 1、代码行 2、代码行 13 或者代码行 14 处设置了断点,gdb 再次停下来的时候,可能会停在到代码行 1 、代码行 2 、代码行 13、代码行 14 这样的地方。

这是多线程程序的特点:当我们从代码行 4 处让程序继续运行时,线程 A 虽然会继续往下执行,下一次应该在代码行 14 处停下来,但是线程 B、C、D 也在同步运行呀,如果此时系统的线程调度将 CPU 时间片切换到线程 B、C 或者 D 呢?那么 gdb 最终停下来的时候,可能是线程 B、C、D 触发了 代码行 1 、代码行 2 、代码行 13、代码行 14 处的断点,此时调试的线程会变为 B、C 或者 D,而此时打印相关的变量值,可能就不是我们期望的线程 A 函数中的相关变量值了。

还存在一个情况,我们单步调试线程 A 时,我们不希望线程 A 函数中的值被其他线程改变。

针对调试多线程存在的上述状况,gdb 提供了一个在调试时将程序执行流锁定在当前调试线程的命令选项——scheduler-locking 选项,这个选项有三个值,分别是 on、step 和 off,使用方法如下:

set scheduler-locking on/step/off

set scheduler-locking on 可以用来锁定当前线程,只观察这个线程的运行情况, 当锁定这个线程时, 其他线程就处于了暂停状态,也就是说你在当前线程执行 next、step、until、finish、return 命令时,其他线程是不会运行的。

需要注意的是,你在使用 set scheduler-locking on/step 选项时要确认下当前线程是否是你期望锁定的线程,如果不是,可以使用 thread + 线程编号 切换到你需要的线程再调用 set scheduler-locking on/step 进行锁定。

set scheduler-locking step 也是用来锁定当前线程,当且仅当使用 next 或 step 命令做单步调试时会锁定当前线程,如果你使用 until、finish、return 等线程内调试命令,但是它们不是单步命令,所以其他线程还是有机会运行的。相比较 on 选项值,step 选项值给为单步调试提供了更加精细化的控制,因为通常我们只希望在单步调试时,不希望其他线程对当前调试的各个变量值造成影响。

set scheduler-locking off 用于关闭锁定当前线程。

我们以一个小的示例来说明这三个选项的使用吧。编写如下代码:

01 #include 02 #include 03 #include 04 05 long g = 0; 06 07 void* worker_thread_1(void* p) 08 { 09 while (true) 10 { 11 g = 100; 12 printf("worker_thread_1\n"); 13 usleep(300000); 14 } 15 16 return NULL; 17 } 18 19 void* worker_thread_2(void* p) 20 { 21 while (true) 22 { 23 g = -100; 24 printf("worker_thread_2\n"); 25 usleep(500000); 26 } 27 28 return NULL; 29 } 30 31 int main() 32 { 33 pthread_t thread_id_1; 34 pthread_create(&thread_id_1, NULL, worker_thread_1, NULL); 35 pthread_t thread_id_2; 36 pthread_create(&thread_id_2, NULL, worker_thread_2, NULL); 37 38 while (true) 39 { 40 g = -1; 42 printf("g=%d\n", g); 42 g = -2; 43 printf("g=%d\n", g); 44 g = -3; 45 printf("g=%d\n", g); 46 g = -4; 47 printf("g=%d\n", g); 48 49 usleep(1000000); 50 } 51 52 return 0; 53 }

上述代码在主线程(main 函数所在的线程)中创建了了两个工作线程,主线程接下来的逻辑是在一个循环里面依次将全局变量 g 修改成 -1、-2、-3、-4,然后休眠 1 秒;工作线程 worker_thread_1、worker_thread_2 在分别在自己的循环里面将全局变量 g 修改成 100 和 -100。

我们编译程序后将程序使用 gdb 跑起来,三个线程同时运行,交错输出:

[root@myaliyun xx]# g++ -g -o main main.cpp -lpthread [root@myaliyun xx]# gdb main ...省略部分无关输出... Reading symbols from main... (gdb) r Starting program: /root/xx/main [Thread debugging using libthread_db enabled] ...省略部分无关输出... [New Thread 0x7ffff6f56700 (LWP 402)] worker_thread_1 [New Thread 0x7ffff6755700 (LWP 403)] g=-1 g=-2 g=-3 g=-4 worker_thread_2 worker_thread_1 worker_thread_2 worker_thread_1 worker_thread_1 g=-1 g=-2 g=-3 g=-4 worker_thread_2 worker_thread_1 worker_thread_1 worker_thread_2 worker_thread_1 g=-1 g=-2 g=-3 g=-4 worker_thread_2 worker_thread_1 worker_thread_1 worker_thread_2

我们按 Ctrl + C 将程序中断下来,如果当前线程不在主线程,可以先使用 info threads 和 thread id切换到主线程:

^C Thread 1 "main" received signal SIGINT, Interrupt. 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 (gdb) info threads Id Target Id Frame * 1 Thread 0x7ffff7feb740 (LWP 1191) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 2 Thread 0x7ffff6f56700 (LWP 1195) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 3 Thread 0x7ffff6755700 (LWP 1196) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 (gdb) thread 1 [Switching to thread 1 (Thread 0x7ffff7feb740 (LWP 1191))] #0 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 (gdb)

然后在代码 11 行和 41 行各加一个断点。我们反复执行 until 48 命令,发现工作线程 1 和 2 还是有机会被执行的。

(gdb) b main.cpp:41 Breakpoint 1 at 0x401205: file main.cpp, line 41. (gdb) b main.cpp:11 Breakpoint 2 at 0x40116e: file main.cpp, line 11. (gdb) until 48 0x00007ffff704c884 in usleep () from /usr/lib64/libc.so.6 (gdb) worker_thread_2 [Switching to Thread 0x7ffff6f56700 (LWP 1195)] Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb) worker_thread_2 [Switching to Thread 0x7ffff7feb740 (LWP 1191)] Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) worker_thread_1 worker_thread_2 g=-1 g=-2 g=-3 g=-4 main () at main.cpp:49 49 usleep(1000000); (gdb) worker_thread_2 [Switching to Thread 0x7ffff6f56700 (LWP 1195)] Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb)

现在我们再次将线程切换到主线程(如果 gdb 中断后当前线程不是主线程的话),执行 set scheduler-locking on 命令,然后继续反复执行 until 48 命令。

(gdb) set scheduler-locking on (gdb) until 48 Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) until 48 g=-1 g=-2 g=-3 g=-4 main () at main.cpp:49 49 usleep(1000000); (gdb) until 48 Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) g=-1 g=-2 g=-3 g=-4 main () at main.cpp:49 49 usleep(1000000); (gdb) until 48 Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) g=-1 g=-2 g=-3 g=-4 main () at main.cpp:49 49 usleep(1000000); (gdb) until 48 Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb)

我们再次使用 until 命令时,gdb 锁定了主线程,其他两个工作线程再也不会被执行了,因此两个工作线程无任何输出。

我们再使用 set scheduler-locking step 模式再来锁定一下主线程,然后再次反复执行 until 48 命令。

(gdb) set scheduler-locking step (gdb) until 48 worker_thread_2 worker_thread_1 g=-100 g=-2 g=-3 g=-4 main () at main.cpp:49 49 usleep(1000000); (gdb) until 48 worker_thread_2 [Switching to Thread 0x7ffff6f56700 (LWP 1195)] Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb) until 48 worker_thread_2 worker_thread_1 Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb) until 48 worker_thread_2 [Switching to Thread 0x7ffff7feb740 (LWP 1191)] Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) until 48 worker_thread_1 worker_thread_2 g=-100 g=-2 g=-3 g=-4 main () at main.cpp:49 49 usleep(1000000); (gdb) until 48 worker_thread_2 [Switching to Thread 0x7ffff6f56700 (LWP 1195)] Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb) until 48 worker_thread_2 worker_thread_1 Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb)

可以看到使用 step 模式锁定的主线程,在使用 until 命令时另外两个工作线程仍然有执行的机会。我们再次切换到主线程,然后使用 next 命令单步调试下试试。

(gdb) info threads Id Target Id Frame 1 Thread 0x7ffff7feb740 (LWP 1191) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 * 2 Thread 0x7ffff6f56700 (LWP 1195) "main" worker_thread_1 (p=0x0) at main.cpp:11 3 Thread 0x7ffff6755700 (LWP 1196) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 (gdb) thread 1 [Switching to thread 1 (Thread 0x7ffff7feb740 (LWP 1191))] #0 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 (gdb) set scheduler-locking step (gdb) next Single stepping until exit from function nanosleep, which has no line number information. 0x00007ffff704c884 in usleep () from /usr/lib64/libc.so.6 (gdb) next Single stepping until exit from function usleep, which has no line number information. main () at main.cpp:40 40 g = -1; (gdb) next Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) next g=-1 42 g = -2; (gdb) next 43 printf("g=%d\n", g); (gdb) next g=-2 44 g = -3; (gdb) next 45 printf("g=%d\n", g); (gdb) next g=-3 46 g = -4; (gdb) next 47 printf("g=%d\n", g); (gdb) next g=-4 49 usleep(1000000); (gdb) next 40 g = -1; (gdb) next Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) next g=-1 42 g = -2; (gdb) next 43 printf("g=%d\n", g); (gdb) next g=-2 44 g = -3; (gdb) next 45 printf("g=%d\n", g); (gdb) next g=-3 46 g = -4; (gdb) next 47 printf("g=%d\n", g); (gdb) next g=-4 49 usleep(1000000); (gdb) next 40 g = -1; (gdb) next Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb)

此时我们发现设置了以 step 模式锁定主线程,工作线程不会在单步调试主线程时被执行,即使在工作线程设置了断点。

最后我们使用 set scheduler-locking off 取消对主线程的锁定,然后继续使用 next 命令单步调试。

(gdb) set scheduler-locking off (gdb) next worker_thread_2 worker_thread_1 g=-100 42 g = -2; (gdb) next worker_thread_2 [Switching to Thread 0x7ffff6f56700 (LWP 1195)] Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb) next g=100 g=-3 g=-4 worker_thread_2 12 printf("worker_thread_1\n"); (gdb) next worker_thread_1 13 usleep(300000); (gdb) next worker_thread_2 [Switching to Thread 0x7ffff7feb740 (LWP 1191)] Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 41 printf("g=%d\n", g); (gdb) next [Switching to Thread 0x7ffff6f56700 (LWP 1195)] Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 11 g = 100; (gdb) next g=-1 g=-2 g=-3 g=-4 worker_thread_2 12 printf("worker_thread_1\n"); (gdb)

取消了锁定之后,单步调试时三个线程都有机会被执行,线程 1 的断点也会被正常触发。

至此,我们搞清楚了如何利用 set scheduler-locking 选项来方便我们调试多线程程序。

总而言之,熟练掌握 gdb 调试等于拥有了学习优秀 C/C++ 开源项目源码的钥匙,只要可以利用 gdb 调试,再复杂的项目,在不断调试和分析过程中总会有搞明白的一天。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有